feat(core,react): row predicates on runtime record surfaces bind record.* only — the bare-field and data.* spellings retire (objectui#5741) - #7846
Conversation
…rd.* only — the bare-field and data.* spellings retire (objectui#5741)
Phase 2 of the row-predicate canon ruled on objectui#5330 (2026-08-20, B) and
executed as ruled on objectui#5741 (2026-09-02, amended 2026-09-05: Q1 = A,
Q2 = A, Q3 = (i)).
- `evalRowPredicate`'s non-rowless scope bag is `{ ...hostScope, record: rowObj }`
for both dialect paths — no bare-field spread, no `data`; `record` stays pinned
after the host spread (objectui#3796).
- `usePredicateRecordContext` returns `{ record: row }`; the empty bag for the
no-row case stays.
- The Phase-1 warning goes with the bindings: both `warnNonCanonicalRowSpelling`
calls, their imports, and the runtime half of `rowPredicateCanon.ts`
(`warnNonCanonicalRowSpelling`, `resetRowPredicateCanonWarnings`) are removed.
`detectNonCanonicalRowSpelling`, `ROW_PREDICATE_CANONICAL_ROOT` and
`NonCanonicalRowSpelling` stay exported as the offline instrument.
- A retired spelling faults exactly as it already did on the server; each surface
applies its existing fault policy. No runtime detector, no "as absent" case.
- Pins flipped to the ruled truth at every surface that held the three-way
binding; a mechanical pin over the schema-catalog corpus lands beside the
detector pins; docblocks describe the post-Phase-2 truth.
- Changeset: `@object-ui/core` + `@object-ui/react` minor, ruled wording.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAZFhALsQsGqxui8sNqM8s
…-predicate-phase2-retire
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
|
Generated by Claude Code |
…29 mock factories (objectui#7337)
A `vi.mock('@object-ui/i18n', () => ({ … }))` factory that hand-lists its
exports freezes the mock to the names that existed the day it was written.
The next module-scope read of a new export kills the importing file during
COLLECTION, so the suite reports zero failed assertions and the red lands on
an innocent test file.
- convert 29 frozen factories to the obtain-and-spread form;
- delete `apps/console/dev/__tests__/setup/common-mocks.ts`, a mock helper
with zero importers repo-wide carrying the same shape;
- fix the recogniser's nested-generic blind spot: `<[^>]*>` stopped at the
first `>`, so `vi.importActual<Record<string, unknown>>(…)` went unmatched
and four factories that obtain AND spread the real module were reported as
frozen. Repo-wide across all 21 workspace specifiers: 349 frozen -> 344,
with no site moving the other way;
- pin the failure mode itself: three nested vitest runs over a throwaway
package show the frozen factory dying at collection, the converted one
passing, and a lazy read failing as an ordinary assertion.
`COVERED_SPECIFIERS` is deliberately NOT widened: one frozen factory remains
in `DeclaredActionsBar.test.tsx`, held by open PR #7846, and flipping the
ratchet while it exists turns `main` red on merge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDq78vMMSzCGWGmhUYBabh
Fixes #5741
What this is
Phase 2 of the row-predicate canon ruled on #5330 (2026-08-20, option B), executed exactly as ruled on this card: ruling B of 2026-09-02 (comment 5511170986) as amended on 2026-09-05 (comment 5555359327) — Q1 = A, Q2 = A, Q3 = (i). Runtime record surfaces now bind
record.*only; the bare-field shorthand and thedata.*root stop being bound there, for both dialects; the Phase-1 warning (PR #5737) goes with the bindings. No survey, no export, no migration rewrite, no sizing (「不考虑存量」). Contract-review tier, Clause-② yes: published runtime behaviour removed. The PR stays draft;needs:contract-reviewis attached.Placeholders in this body are spelled as words because GitHub's sanitizer eats angle-bracket shapes (so "a Record of string to any", never the literal generic).
Premise, re-verified on
900f8d99before editingpackages/core/src/evaluator/listConditional.ts:273built{ ...(opts.scope ?? {}), ...rowObj, data: rowObj, record: rowObj };:317calledwarnNonCanonicalRowSpelling(predicateText, rowObj, !opts.rowless, opts.label)inside the CEL guard; the legacy${…}branch at:298and the CEL calls at:327/:329consumed the samescope.packages/react/src/hooks/useExpression.ts:170returned{ ...row, record: row, data: row };:251was the second warning call;:255served both dialects from one evaluator.rowPredicateCanon.tsexportedROW_PREDICATE_CANONICAL_ROOT(:89),NonCanonicalRowSpelling(:98),detectNonCanonicalRowSpelling(:145),resetRowPredicateCanonWarnings(:208),warnNonCanonicalRowSpelling(:221).declaredPredicate.tsholds no binding and is untouched.views/metadata-admin/predicate.tsbuildPredicateCtxbinds{ data: draft, … }through its ownevaluatePredicate, neverevalRowPredicate/usePredicateRecordContext. Itsdata.*pins (predicate.test.ts,SchemaForm.visibleWhen.test.tsx) are the ruled control: byte-for-byte untouched (git diffon that directory is empty) and green in every union below.evalRowPredicateon{ status: 'active' }—record.statustrue, barestatustrue,data.statustrue; on a record-only engine scope the bare anddata.*spellings fault[type] Unknown variable: status/Unknown variable: data. Recorded in the measurement script the report names.git log origin/main -- packages/core/src/evaluator packages/react/src/hooksshowed nothing after900f8d99at dispatch. Five commits landed during the run (origin/main52cac388, merged here as14384aa1): they touchpackages/core/src/utils/*,packages/react/src/hooks/useNavigationOverlay.tsand changesets — disjoint from this PR. The When bumping @objectstack/spec past objectstack#14075: list-view spec-parity pins need the CalendarConfig titleField-optional update #7122 chain (PR chore(deps): resolve @objectstack/spec at 17.3.0 in the lockfile #7685) and@object-ui/types' Phase-2ActionCallbacksurface is a thirdonSuccessmeaning — declared, zod-mirrored, doc-advertised, zero producers #7068 hold none of these paths.What moved — 17 files, nothing else
Source (3):
packages/core/src/evaluator/listConditional.ts— the non-rowless scope is{ ...(opts.scope ?? {}), record: rowObj }for BOTH dialect paths (recordstill pinned after the host spread, objectui#3796, its comment kept); thewarnNonCanonicalRowSpellingcall and import removed; theevalRowPredicatedocblock, thescope/rowlessoption docs and the two comment blocks now describe the post-Phase-2 truth (record-only, faults, no warning, layer rule).packages/core/src/evaluator/rowPredicateCanon.ts— the runtime warning half deleted (warnNonCanonicalRowSpelling,resetRowPredicateCanonWarnings, the dev gate, the warn-once memo);detectNonCanonicalRowSpelling,ROW_PREDICATE_CANONICAL_ROOTandNonCanonicalRowSpellingstay exported; the canon docblock rewritten. The core barrelexport *s this module, so the two deletions are public-export removals — named in the changeset, as the brief asks;git grepacrosspackages/ apps/ examples/ scripts/ content/ skills/found no importer outside the evaluator's own tests exceptuseExpression.tsand the reactuseCondition.canonSpellingpin, both on this PR.packages/react/src/hooks/useExpression.ts—usePredicateRecordContextreturns{ record: row }; the{}for the no-row / non-object case stays, and its ledger spellingtypeof record !== 'object' || Array.isArray(record)is unchanged, soconfigBag.pin.test.tsneeded no edit (green); the Phase-1 block inuseConditionand the two now-unused imports removed; the docblock that declares the two sites one rule keeps the declaration with new text; theuseRowPredicatedoc updated.Pins (13) — every pin that held the three-way binding was rewritten to the ruled verdict, never deleted silently; each rewrite cites objectui#5741 in its describe / it text. The brief's list, each line re-verified on this tree, plus five more three-way pins the repo-wide grep found:
packages/core/src/evaluator/__tests__/rowPredicateCanon.test.ts— detector pins unchanged; the binding block now pinsrecord.*discriminating, four retired spellings (bare,data.*,${data.x},${x}) at the caller fallback on BOTH rows with both fallbacks driven, the fault warning on both routes,partitionRowsByPredicate, the host's owndataleft standing, the Phase-1 warning gone, and the two removed exports absent from the barrel.packages/core/src/evaluator/__tests__/rowPredicateCanon.schemaCatalog.test.ts— NEW: the mechanical slice of the corpus sweep (below).packages/core/src/evaluator/__tests__/listConditional.test.ts—:41,:97-98,:104,:256-258,:282-286,:436-440flipped;:480(rowless, legacydata.tag === 'HOST') needed no flip — a rowless caller keeps the host scope's owndata, so its verdict did not move (green, unchanged).packages/react/src/hooks/__tests__/useCondition.canonSpelling.test.tsx— rewritten: the bag shape,record.*on both legs, four retired spellings on the throwing leg (hidden on both rows, reported once naming the variable) and the non-throwing leg (fail-softtrueon both rows), the ambient-dataconsequence, no Phase-1 warning.packages/react/src/hooks/__tests__/useExpression.test.ts— theuseRowPredicatebare-field case (:154, whose fallback direction flipped) and a row-present shape pin for the helper.packages/components/src/__tests__/page-header-predicate-dialect.test.tsx:207-216— bare /data.*hidden on both rows (page:header is fail-closed).packages/components/src/renderers/action/__tests__/action-record-predicate-root.test.tsx— the four generic renderers plus theaction:baroverflow:record.*rows kept, bare /data.*re-pinned as "same verdict on both rows" with each site's policy (hidden on the throwingvisiblelegs, shown / greyed / enabled on the fail-soft legs).packages/components/src/renderers/complex/__tests__/data-table-row-action-visible.test.tsx:76-81— barerole != 'owner'hidden on both rows.packages/app-shell/src/views/__tests__/DeclaredActionsBar.test.tsx—:513-521and:626re-spelledrecord.*with new bare-field pins beside them (fail-closedvisible: hidden on both rows, reported once; fail-softdisabled: greyed on both rows); the legacy rows:536/:646re-spelled${record.status …};:703-721split into therecord.*case and the retired pair (app-shell SOURCE untouched).packages/plugin-detail/src/renderers/__tests__/record-alert.rowBinding.test.tsx— group B: shorthand SHOWN on both rows (fail-soft), legacydata.*OFF on both rows (the ambientdata: {}answers), the row still wins over an ambientrecordwhile a hostdatais left standing; group C: shorthand no longer gates; the header docblock rewritten. The two sibling record-alert pins import no canon symbol and use onlyrecord.*— unchanged.packages/plugin-grid/src/__tests__/predicate-surface-parity.test.tsx:192-193— bare /data.*expectedfalseon all three surfaces (the parity claim now holds for the retirement).packages/plugin-kanban/src/cardPredicateScope.test.tsx:79-85— a bare-field formatting condition binds nothing (no style);record.idon the same card is the control.packages/plugin-list/src/__tests__/ListView.test.tsx:2277-2283— the spec-format formatting case re-spelled${record.status …}, with a retired${data.status …}case beside it (no style on either row).Changeset (1):
.changeset/row-predicate-phase2-record-only-5741.md—@object-ui/coreand@object-ui/reactminor(same fixed group); first line is the ruled wording verbatim; then the per-surface fault shapes (Q1 A), the warning removal with the two export names, the detector staying exported, the layer rule, and the release fact: Phase 1 (PR #5737) shipped in@object-ui/core@17.6.0(npm 2026-08-24, per report 5551793236) while.changeset/row-predicate-record-canon-5330.mdis still pending onmain, so the next CHANGELOG section reads both phases together.Before / after, measured on the built dist (BASE
900f8d99versus this branch)evalRowPredicate(row kebab, selection bar,page:header, conditional formatting)record.status == 'active'status == 'active'fallback: true)Unknown variable: status, with therecord.hint on the fast routedata.status == 'active'Unknown variable: data${data.status === "active"},${status === "active"},status === 'active'(legacy path)[legacy] … is not definedwhenwarnOnErrorpartitionRowsByPredicateover [active, closed]data.*useCondition, throwing legs (action:buttonandaction:menuvisible, theaction:baroverflow,DeclaredActionsBarvisible)data.*,${data.x},${x}was hidden/disabled: its predicate threw — … status is not defineduseCondition, non-throwing legs (action:iconandaction:groupvisible, everydisabled/enabled,record:alert)trueon both rows (shown / greyed / enabled)Failed to evaluate expressionlineuseConditionleg under a host scope that carriesdata(app-shell's ambientdata: {})data.status == 'x'falseon both rows, silentdataanswersSchemaForm/predicate.ts(the control)data.type == 'list'The fault warning after the removal — verified, reported, not rewritten
evalFieldPredicate's one-time warning infieldRules.ts): names the variable and carries the hint —A conditional predicate failed to evaluate (row action "approve") and was treated as its safe default (false): "amount > 5". Reason: [type] Unknown variable: amount … Values are bound under 'record.' (e.g. record.status). Pinned.warnEvalError,warnOnError: true): names the variable, norecord.hint —… Reason: [type] Unknown variable: data … Check the field names and CEL syntax.Pinned; report line, not a rewrite.useConditionthrowing legs:… was hidden/disabled: its predicate threw — Failed to evaluate expression "status == 'in_review'": status is not defined. Predicate: …. Pinned.data— app-shell'sExpressionProvidermountsdata: {}on every record page — adata.*row predicate does not fault at all: it reads the host's object and is a constantfalsewith no console line (the pre-finding: record:alert 是唯一没接 #4075/#4077 行绑定的谓词面 —— 裸 { record } 作用域下 row-action 简写与 data.* 拼法 unbound,fail-soft 使作者门控的横幅永远显示 #4807 record-alert polarity, now by ruling). A detector there would be the B / C behaviour the ruling rejected, so it is pinned as measured (useCondition.canonSpelling,record-alert.rowBinding) and stated in the changeset and the canon docblock.Docs — zero diff, as expected
git grepovercontent/docsfor the Phase-1 prose (deprecated spelling, stored-metadata survey, deprecation window, non-canonical) hits onlyguide/console-architecture.md:183, which is about something else; the onedata.*CEL teaching hit,core/enhanced-actions.mdx:245(condition: 'data.amount > 1000'), is an ActionRunnerconditionevaluated on the runner's own context, not a row surface.skills/**is governed and carried by #7728; the metadata-admin editor by #7727;content/docs/releases/untouched.Corpus sweep — PR #5758's recipe, re-run on this tree
Key-agnostic string-literal scan (every literal carrying a comparison / boolean operator), classified by CEL root with
@objectstack/formula'scollectCelRootIdentifiers/firstUndeclaredReferenceand bydetectNonCanonicalRowSpelling. 1,016 files, 82,981 literals, 296 ms; identical counts on14384aa1.content/docsexamples(incl. schema-catalog)appspackages/*/README.mdskillsdocs1,315 predicate-shaped strings; roots:
${…}legacy 141,record77,data4, bare undeclared 79. Positive controls:record.77 anddata4 both reached; the classifier reportsbare-shorthand/metadata-layer-rooton the control spellings and nothing on the canon / host-scope ones.Every non-canonical hit read in source:
data.*(4):apps/console/src/components/FormPage.fieldSpec.test.ts:127is avisibleOntype-face fixture (schema / widget tier, wheredatais the form scope);content/docs/components/data-display/tree-view.mdx:59is prose (bind || nodes || data);skills/objectui/guides/schema-expressions.md:220and:232are thevisibleOn/hiddenOntier on finding(skills):skills/objectui/guides/schema-expressions.mdteaches the bare-field anddata.*row spellings as "deprecated and warned once in dev" — stale once #5741 (Phase 2) retires them #7728's governed surface.${data.x}with an operator (28):content/docsteaching pages,apps/consolefixtures and the ActionRunnerconditionexamples — the${…}dialect on schema / widget nodes and runner contexts, the class PR docs(plugin-form): Phase 0 of the row-predicate deprecation — stop teaching the bare shorthand (#5738) #5758 stood down on; none is a row surface.apps/console/src/preview-samples.ts(condition: 'status == "active"'on a flowstarttrigger,criteria: "status == 'Closed'"on a workflow rule,amount,discount,daysToExpiry), formula-field expressions (content/docs/fields/formula.mdx:68-69), a viewfilterdescribed in prose (guide/building-crud-app.md:342), diagnostics examples with a deliberate unknown root (guide/metadata-diagnostics.md:181-182), JS expressions in test files (children || body,length > 0), UI strings ("Open in GitHub"), CHANGELOG entries and ADR prose (docs/adr/0036). None is a runtime record surface.fieldTabs[].visibleWhen: 'status == "won"'incontent/docs/plugins/plugin-form.mdx:173andpackages/plugin-form/README.md:446— the fieldRules tier bindsrecordonly and always did, and a broken tab predicate fails open. Filed as objectui#7834; not edited here.examples/schema-catalog/src/schemas, 432 JSON documents, 7,302 strings): 20 predicate-shaped — 4record.*, 1current_user.*, 14 display text that does not parse as CEL — 0data.*, 0 legacy, 0 bare. Clean.Pin decision: the full recipe is deterministic and fast but not MECHANICAL — 79 of its hits need tier judgement — so it stays this reading. Its mechanical slice IS landed:
rowPredicateCanon.schemaCatalog.test.tssweeps the catalog JSON from disk (rooted at the test file per objectui#7799, enumerated rather than listed, no new dependency edge —@objectstack/formulais already core's), with a positive control (documents present, parsed CEL predicates present) and a classifier control.Gates — exit captured before any pipe, verdict lines quoted, head
14384aa1unless notedpnpm --filter @object-ui/core builddist completeness: 1 package(s) complete (192 emitted files verified)pnpm --filter @object-ui/react builddist completeness: 1 package(s) complete (130 emitted files verified)pnpm exec vitest run --maxWorkers=2overpackages/core/src/evaluator/,packages/react/src/hooks/,configBag.pin.test.ts, the six consumer pin files,packages/plugin-detail/src/renderers/__tests__/, and the two metadata-admin control pinsTest Files 79 passed (79) · Tests 1357 passed (1357)pnpm exec vitest run --maxWorkers=2 packages/react/ packages/app-shell/src/views/metadata-admin/ packages/components/src/renderers/action/Test Files 313 passed (313) · Tests 3466 passed, 1 skipped (3467)(lock held 7m01s)pnpm --filter @object-ui/core type-check·react·components·app-shell·plugin-detail(eachtsc --noEmit && tsc -p tsconfig.test.json, against the rebuilt dists)error TSin each log; script names echoedpnpm --filter @object-ui/core lint·pnpm --filter @object-ui/react lint0 errors, 516 warnings·0 errors, 347 warnings(on14384aa1; warnings are not CI-blocking and are the files' own — see the narrowing note)node scripts/check-changeset-presence.mjs16 source file(s) of 8 released package(s) changed, and this change declares 1 changeset(s)check-changeset-fixed·check-changeset-no-major·check-changeset-overwriteprivatePackages declared·No changeset declares a major bump·No pre-existing changeset was modified or deletednode scripts/check-control-bytes.mjsOK (scanned 6367 tracked text file(s); skipped 85 binary)check-unreferenced-sources·check-vi-mock-specifiers·check-vi-mock-inherit·check-shell-escape-residuenode scripts/check-readme-exports.mjs75 self-import(s) could not be judged … type entry ./dist/index.d.ts is not on diskfor app-shell / cli / plugin-ai / plugin-gantt (unbuilt here); it names none of this PR's files or symbols, and no README imports the two removed exports (git grep). CI builds the tree.node scripts/pm/check-governed-merges.mjs --teston the 17-file list, from objectstack)0 of 17 path(s) hit the register … NOT governedLint, declared narrowing: repo-wide
pnpm lintwas not run. Population:eslint.config.jsenables no type-aware linting (noparserOptions.project/projectService), so this diff cannot move any untouched file's verdict; the PR's own 16 code files were linted with--format json— 16 files, 0 errors, 78 warnings, run the way CI'sturbo run lintruns it (package-leveleslint ., no--no-inline-config). OnuseExpression.tsthe 9no-explicit-anywarnings are the BASE file's own: eslint on the BASE blob via stdin at the same path reports 9, the current file reports 9.Ablation — Clause-② yes
Run on
6e9c7a6e, the implementation commit; the merge left both mutated files byte-identical (git diff 6e9c7a6e 14384aa1on the two paths is empty). vitest resolves@object-ui/coreand@object-ui/reacttosrcthrough thevitest.config.mtsaliases (:408,:413), so no dist is on the resolution path and no rebuild leg applies. Each mutation was proven on disk before the run (grep -cinjected = 1, removed = 0, and the blob hash) and restored withgit checkout HEAD -- pathunder atrapon EXIT / INT / TERM; restoration proven bygit diff HEADempty andgit hash-objectequal to the HEAD blobs (62f0181b…forlistConditional.ts,320a8da7…foruseExpression.ts).{ ...scope, ...rowObj, data: rowObj, record: rowObj }:Test Files 4 failed | 1 passed (5) · Tests 18 failed | 110 passed (128). Every red is a retired-spelling pin (rowPredicateCanon×9,listConditional×5,predicate-surface-parity×2,page-header×2); everyrecord.*pin green; the react tier's file untouched and green, because the hook is the second, independent site.{ ...row, record: row, data: row }:Test Files 5 failed | 1 passed (6) · Tests 38 failed | 122 passed (160). Every red is a retired-spelling pin (useCondition.canonSpelling, theuseExpressionshape pin,action-record-predicate-root×20,DeclaredActionsBar×3,record-alert.rowBinding×4); everyrecord.*pin green; core'srowPredicateCanonfile green.Out of scope, filed
fieldTabs[].visibleWhenexample teaches the bare spelling on the fieldRules tier (a Phase-0 class defect, one block below the one PR docs(plugin-form): Phase 0 of the row-predicate deprecation — stop teaching the bare shorthand (#5738) #5758 corrected).finding: renderer comments in the four action renderers,containers.tsx,record-alert.tsx,SchemaRenderer.tsxandDeclaredActionsBar.tsxstill describe the three-way binding; Blocked-by Carrier for Phase 2 of the row-predicate deprecation: the stored-metadata survey that must size the removal window before any spelling retires #5741. It also records thectx.dataalias onpage:headeras an observation (a separatectx.*namespace, objectui#2358, not ruled on).flattenedscope and advertises bare fields — declared-but-unbound once #5741 (Phase 2) retires the bare-field binding #7727 and finding(skills):skills/objectui/guides/schema-expressions.mdteaches the bare-field anddata.*row spellings as "deprecated and warned once in dev" — stale once #5741 (Phase 2) retires them #7728 stay Blocked-by Carrier for Phase 2 of the row-predicate deprecation: the stored-metadata survey that must size the removal window before any spelling retires #5741 and unlock when this lands; not touched here.Generated by Claude Code